<?php

/**
 * An object that represents a record in database
 */
class Model {

    /**
     *
     * @var array
     */
    private $data = NULL;
    /**
     *
     * @var array
     */
    private $schema = NULL;
    /**
     * The manager database reference
     * @var Database
     */
    private $db = NULL;
    /**
     *
     * @var string
     */
    protected $tableName = NULL;
    
    /**
     *
     * @var string
     */
    protected $prefix = true;
    
    /**
     *
     * @var string
     */
    protected $primaryKey = "id";
    /**
     *
     * @var mixed
     */
    private $keyValue = NULL;
    /**
     *
     * @var array
     */
    private $fields = NULL;
	
	/**
	 * @var Model
	 */
	private static $instance = NULL;
    /**
     *
     * @var bool
     */
    public $debug = 0;
    /**
     * The list of Helper Classes to use in this Model
     *
     * @var array
     */
    protected $helpers = array('HTML');

    /**
     * Constructs a Model with the specified parameters. The $data parameter
     * can either be an array containing the record for the object or an int or string
     * representing the primary key value for the Model.
     * 
     * Unless otherwise specified via the primaryKey variable override, the primary
     * key field for the Model is "id".
     * 
     * @param mixed $data the key or record data
     * @param string $tableName the name of the table for which this object belongs
     * @param mixed $keyValue the value of the primary key column
     */
    public function __construct($data = NULL, $tableName = NULL, $keyValue = NULL) {

        // several parameters can used to construct a Model, so we'll check
        // if the first value supplied is an array or an int and provide a mechanism
        // to map the data as necessary
        if (is_array($data)) {
            // map the data for this object
            $this->setDataArray($data);
        }

        // user supplied an ID value instead
        else if (is_int($data) || intval($data) > 0 || is_string($data)) {
            //$this->setId($data);
            $this->keyValue = $data;
        }

        // map the table name for this object
        if ($tableName != NULL) {
            $this->tableName = $tableName;
        }

        // map the key for this object, if one is supplied, so as not to override
        // the possibility of passing an ID as the first param
        if ($keyValue != NULL) {
            $this->keyValue = $keyValue;
        }

        // initiate the helper classes available for use by this Model
        if ($this->helpers != NULL) {
            foreach ($this->helpers as $helper) {
                if (class_exists($helper) && is_subclass_of($helper, 'Helper')) {
                    $this->{$helper} = new $helper();
                }
            }
        }
    }

    /**
     * Returns the Primary Key Column name for this object
     * 
     * @return string
     */
    public function getPrimaryKey() {
        return $this->primaryKey;
    }

    /**
     * Sets the value of the primary key column
     * @param mixed $id
     */
    public function setId($id) {
        return $this->setData($this->getPrimaryKey(), $id);
    }

    /**
     * Returns the value of the primary key column
     * 
     * @return mixed
     */
    public function getId() {
        if ($this->keyValue != NULL) {
            return $this->keyValue;
        }

        return $this->data != NULL && isset($this->data[$this->primaryKey]) ?
            $this->data[$this->primaryKey] : NULL;
    }

    /**
     * Returns the database tablename for this Model
     * 
     * @return string
     */
    public function getTableName() {
        if( (defined("DB_PREFIX") && DB_PREFIX != "" && $this->prefix == true ) || ($this->prefix != true && $this->prefix != "") ) {
            
            // give preference to local prefix if present
            if( $this->prefix != false && $this->prefix != "" && strpos($this->tableName, $this->prefix) == false ) {
                return $this->prefix . $this->tableName;
            }
            
            else if( strpos($this->tableName, DB_PREFIX) == false ) {
                return DB_PREFIX . $this->tableName;
            }
        }
        
        return $this->tableName;
    }
    
    public function field($key) {
        return $this->getData($key);
    }

    /**
     *
     * @param string $key the key to use to retrieve the field data
     * @return mixed the data
     */
    public function getData($key) {
        // call the getDataArray function to return the data array
        // this preferred so as to load the object data if it is not 
        // currently present. This can usually occur if the object was initialised
        // with only the primary key information
        $data = $this->getDataArray();

        return isset($data[$key]) ? $data[$key] : NULL;
    }

    /**
     *
     * @param string $key
     * @param mixed $value
     */
    public function setData($key, $value = NULL) {

        // if no data array exists and there is no key value, then create a new array
        if ($this->data == NULL && $this->keyValue == NULL) {
            $this->data = array();
        }

        // if there is a key but there is currently no fetched data, then refresh 
        // the data from the db before attempting to update new values
        else if ($this->data == NULL && $this->keyValue != NULL) {
            $this->refresh();
        }

        // do the update
        if( is_array($key) && $value == NULL ) {
            //$this->data = array_merge($this->data, $key);

            // manually copy the records, since array_merge may or may not override
            // the existing the data
            foreach($key as $k => $v) {
                $this->data[$k] = $v;
                
                if( $k == $this->primaryKey ) {
                    $this->keyValue = $v;
                }
            }
            
        } else if( is_string($key) && $value != NULL ) {
            $this->data[$key] = $value;
        }
        

        // handle the primary key specially so it usable later
        if ($key == $this->primaryKey) {
            $this->keyValue = $value;
        }
		
		return $this;
    }
	
	/**
	 * Creates and returns an instance of this Model
	 *
	 * @param mixed $data an ID or Array containing the model data
	 */
	public function create($data) {
		$className = get_class($this);
		return new $className($data);
	}

    /**
     * Returns an array containing the database record represented by the item
     * with the primary specified
     * 
     * @return array
     */
    public function getDataArray() {
        if ($this->data != NULL && is_array($this->data)) { /* ensure we have data in the first place */
            return $this->data;
        }

        // if there is no keyValue, then we cannot determine the content of this
        // object, so simply return and empty array
        if ($this->keyValue == NULL) {
            return array();
        }

        /**
         * if the data does not exist for this object, then determine it from the database
         */
        $this->refresh();

        return $this->data;
    }

    /**
     * Replaces the contents of the supplied array with the current data
     * array, overwriting the old with the new if the keys are the same
     * 
     * @param array $data
     */
    public function setDataArray($data) {
        if (!is_array($data) && $data != NULL) {
            trigger_error("Invalid Data supplied.");
        }

        // cache the data
        if (is_array($this->data)) {
            $this->data = array_merge($this->data, $data);
        } else {
            $this->data = $data;
        }

        // update the primary value for this record
        if ($data != NULL) {
            $this->keyValue = $this->getData($this->primaryKey);
        }
    }

    /**
     * Returns the database object used by the manager
     *
     * @return Database
     */
    public function getDatabase() {
        if ($this->db == NULL) {
            $this->db = new Database();
        }

        return $this->db;
    }

    /**
     * Replaces the current database object with a new one
     *
     * @param Database $db
     */
    public function setDatabase($db) {
        $this->db = $db;
    }

    /**
     * Returns an array contains on the Column Names from the database table
     * for this Model
     * 
     * @return array
     */
    private function getFields() {
        if ($this->data != NULL && is_array($this->data)) {
            $this->fields = array_keys($this->data);
            return $this->fields;
        }

        return $this->fields;
    }

    /**
     * Returns an array of data containing information about the database table for this
     * model. If the "$field" value is specified, and is a string representing a field in the
     * table, only the info about that field is returned
     * 
     * @param mixed $field
     * @return mixed
     */
    public function schema($field = NULL) {

        if ($this->schema == NULL) {
            $db = $this->getDatabase();
            $data = $db->query("DESCRIBE " . $this->tableName)->getResult();

            foreach ($data as $d) {
                $field_name = $d['Field'];
                unset($d['Field']);

                $this->schema[$field_name] = $d;
            }
        }

        if (isset($field) && is_string($field)) {
            return isset($this->schema[$field]) ? $this->schema[$field] : NULL;
        }

        return $this->schema;
    }

    /**
     * Reloads the object data from the database
     */
    public function refresh() {
        $db = $this->getDatabase();
        $selectSQL = sprintf("SELECT * FROM {$this->getTableName()} WHERE $this->primaryKey = %s", $db->sanitizeInput($this->keyValue, $db->getValueType($this->keyValue)));

        $row = $db->query($selectSQL)->getRow();

        $this->fields = array_keys($row);
        $this->data = $row;
    }

    /**
     * Saves the Model data to the database. An INSERT operation will be performed
     * if the Model primary key value is NULL or empty, and an UPDATE will be
     * performed otherwise.
     */
    public function save() {

        // determine if an insert or update should be carried out
        $doInsert = $this->keyValue == NULL;

        $db = $this->getDatabase();

        // get the field column names
        $data = $this->getDataArray();

        // remove the primaryKey column
        unset($data[$this->primaryKey]);

        // get the fields and values
        $fields = array_keys($data);
        $values = array_values($data);

        if ($doInsert) {

            // concetenate the values
            $valuesStr = "";
            for ($i = 0; $i < count($values); $i++) {
                $valuesStr .= $db->sanitizeInput($values[$i], $db->getValueType($values[$i]));

                if ($i < count($values) - 1) {
                    $valuesStr .= ", ";
                }
            }

            // implode the fields and append the values
            $insertSQL = sprintf("INSERT INTO {$this->getTableName()} (%s) VALUES (%s)", implode(", ", $fields), $valuesStr);

            if ($this->debug) {
                echo $insertSQL . "<br />";
            }

            // run the insert query
            $db->query($insertSQL, true);

            // update the Model with the new primary key value, so a subsequent
            // save update process can performed using the same object
            if ($db->getError() == "") {
                $this->setData($this->primaryKey, $db->getInsertId());
            }
        } else {

            $updateSQL = "UPDATE {$this->getTableName()} SET ";
            for ($i = 0; $i < count($values); $i++) {
                $updateSQL .= $fields[$i] . " = " . $db->sanitizeInput($values[$i], $db->getValueType($values[$i]));

                if ($i < count($values) - 1) {
                    $updateSQL .= ", ";
                }
            }

            $updateSQL .= sprintf(" WHERE {$this->primaryKey} = %s ", $db->sanitizeInput($this->keyValue, $db->getValueType($this->keyValue)));

            if ($this->debug) {
                echo $updateSQL . "<br />";
            }

            $db->query($updateSQL, true);
        }
    }

    /**
     * Deletes the current Model's information from the database.
     * 
     * @return bool
     */
    public function delete($options = NULL) {
        $id = $this->getData($this->primaryKey);

        if ($id <= 0 || $id == NULL) {
            return false;
        }

        $db = $this->getDatabase();

        $query = sprintf("DELETE FROM " . $this->getTableName() . " AS " . get_class($this));


        if ($options != NULL && is_array($options)) {

            if (isset($options['filter'])) {
                $filters = $this->__buildFilters($options['filter']);
                $query = sprintf("%s WHERE %s", $query, implode(" AND ", $filters));
            }

            if (isset($options['page']) && isset($options['limit'])) {
                $options['offset'] = (intval($options['page']) - 1) * $options['limit'];
            } else if (!isset($options['offset'])) {
                $options['offset'] = 0;
            }

            if (isset($options['limit'])) {
                $query = sprintf("%s LIMIT %s", $query, $options['limit']);
            }
        } else {

            $query = sprintf("DELETE FROM " . $this->getTableName() . " WHERE {$this->primaryKey} = %s", $db->sanitizeInput($this->keyValue, $db->getValueType($this->keyValue)));
        }


        // incase the object class name was used at any point, replace it with the table
        // name to ensure the query runs
        //$query = str_replace(get_class($this) . ".", $this->getTableName() . ".", $query);

        $db->query($query, true);

        return true;
    }

    /**
     * Converts and returns the Model's internal array data structure to JSON format
     * 
     * @return string
     */
    public function toJSON() {
        return json_encode($this->getDataArray());
    }

    /**
     * Returns all instances of this model's data from the database as an array
     * Model class objects
     * 
     * @return array
     */
    public function findAll() {
        return $this->find();
    }
    
    /**
     * Returns the first occurances of the Model based on the search options provided
     * 
     * @param array $option
     * @return Model
     */
    public function findFirst($options = NULL) {
		//force the limit of the query to return only one record, irrespective of what limit was passed
		if( $options == NULL ) {
			$options = array('limit' => 1);	
		} else {
			$options['limit'] = 1;	
		}
		
        $items = $this->find($options);
        
        return $items != NULL ? $items[0] : NULL;
    }
    
    public function paginate($start = 0, $limit = 25, $options = NULL) {
		$default = array('offset' => $start, 'limit' => $limit);
		
		if( is_array($options) ) {
			$default = array_merge($default, $options);	
		}
		
		return $this->find($default);
	}

    /**
     * Retrieves instances of this object from the database
     * 
     * @param $options the search parameters
     * @return array
     */
    public function find($options = array()) {
		
		$defaults = array('objects' => true);
		
		$options = array_merge($defaults, $options);
		

        $db = $this->getDatabase();

        //$fields = $this->getTableName() . ".*";
		$fields = get_class($this) . ".*";

        if (isset($options['fields'])) {

            if (is_array($options['fields'])) {
                $fields = implode(", ", $options['fields']);
            } else if (is_string($options['fields'])) {
                $fields = $options['fields'];
            }
        }

        $query = sprintf("SELECT {$fields} FROM " . $this->getTableName() . " AS " . get_class($this) );

        if ($options != NULL && is_array($options)) {

            if (isset($options['join'])) {
                $join_array = $options['join'];

                foreach ($join_array as $key => $value) {
                    $query = sprintf("%s INNER JOIN %s ON (%s) ", $query, $key, $value);
                }
            }

            if (isset($options['joins'])) {
                $joins = $options['joins'];

                if (is_array($joins)) {
                    foreach ($joins as $join) {

                        if (is_array($join)) {
                            $condition = $join['condition'];
                            $keys = array_keys($condition);
                            $values = array_values($condition);

                            if (strtolower($join['type']) == "inner") {
                                $query = sprintf("%s INNER JOIN %s ON (%s) ", $query, $keys[0], $values[0]);
                            } else if (strtolower($join['type']) == "left") {
                                $query = sprintf("%s LEFT JOIN %s ON (%s) ", $query, $keys[0], $values[0]);
                            } else if (strtolower($join['type']) == "right") {
                                $query = sprintf("%s RIGHT JOIN %s ON (%s) ", $query, $keys[0], $values[0]);
                            }
                        }
                    }
                }
            }

			// WHERE
            if (isset($options['filter'])) {
                $filters = $this->__buildFilters($options['filter']);
                $query = sprintf("%s WHERE %s", $query, implode(" AND ", $filters));
            }
			
			// GROUP BY
			if( isset($options['group']) ) {
				$groupInfo = $options['group'];
				$groups = array();
				
				if( is_array($groupInfo) ) {
					foreach($groupInfo as $key => $value) {
						if(is_int($key)) {
							$groups[] = $value;	
						} else {
							$groups[] = $key . " " . $value;
						}
					}
				}
				
				else if( is_string($groupInfo) ) {
					$groups[] = $groupInfo;	
				}
				
				$query = sprintf("%s GROUP BY %s", $query, implode(",", $groups));
			}

			// ORDER
            if (isset($options['order'])) {
                $orderInfo = $options['order'];
                $orders = array();

                if (is_array($orderInfo)) {
                    foreach ($orderInfo as $key => $value) {
                        if (is_int($key)) {
                            $orders[] = $value;
                        } else {
                            $orders[] = $key . " " . $value;
                        }
                    }
                } else if (is_string($orderInfo)) {
                    $orders[] = $orderInfo;
                }

                $query = sprintf("%s ORDER BY %s", $query, implode(",", $orders));
            }
			
            if (isset($options['page']) && isset($options['limit'])) {
                $options['offset'] = (intval($options['page']) - 1) * $options['limit'];
            } else if (!isset($options['offset'])) {
                $options['offset'] = 0;
            }

            if (isset($options['limit'])) {
                $query = sprintf("%s LIMIT %s, %s", $query, $options['offset'], $options['limit']);
            } else if (!isset($options['limit']) && $options['offset'] > 0) {
                $query = sprintf("%s LIMIT %s, %s", $query, $options['offset'], 999999999999);
            }
        }



        // incase the object class name was used at any point, replace it with the table
        // name to ensure the query runs
        //$query = str_replace(get_class($this) . ".", $this->getTableName() . ".", $query);

        // run the query
        return $this->query($query, $options['objects']);
    }
	
	public function getAll($start = 0, $limit = NULL, $options = NULL) {
		$default = array('offset' => $start, 'limit' => $limit);
		
		if( is_array($options) ) {
			$default = array_merge($default, $options);	
		}
		
		return $this->find($default);
	}
	
	public function getById($id) {
		if( intval($id) <= 0 ) {
			trigger_error("A string or int value is required for the ID");	
		}
		
		return $this->findFirst(array(
			'filter' => array(
				$this->primaryKey => $id
			)
		));
	}

    private function __buildFilters($filterInfo) {
        
        $db = $this->getDatabase();
        $filters = array();

        foreach ($filterInfo as $key => $value) {
            
            $safeValues = array();
            $conditionals = array("AND", "OR", "XOR", "NOR");
            $key_upper = strtoupper($key);

            if (!in_array(trim($key_upper), $conditionals) && is_array($value)) {

                if (is_array($value)) {
                    foreach ($value as $val) {
                        $safeValues[] = $db->sanitizeInput($val);
                    }
                }
            }

            if (strpos($key_upper, ' NOT REGEXP') > -1 || strpos($key_upper, ' REGEXP') > -1 ||
                strpos($key_upper, ' NOT LIKE') > -1 || strpos($key_upper, ' LIKE') > -1 || strpos($key_upper, ' <>') > -1 ||
                strpos($key_upper, ' !=') > -1 || strpos($key_upper, ' >') > -1 || strpos($key_upper, ' <') > -1 ||
                strpos($key_upper, ' <=') > -1 || strpos($key_upper, ' >=') > -1 || strpos($key_upper, ' =') > -1) {

                if (!is_array($value)) {
                    $filters[] = $key . ' ' . $db->sanitizeInput($value, $db->getValueType($value));
                }
            } else if (strpos($key_upper, ' IN') > -1 || strpos($key_upper, ' NOT IN') > -1) {

                if (is_array($value)) {
                    $filters[] = $key . ' (' . implode(",", $safeValues) . ')';
                } else {
                    $filters[] = $key . ' (' . $value . ')';
                }
            } else if ( !is_array($value) && (strpos(strtoupper($value), "IS NULL") > -1 || strpos(strtoupper($value), "IS NOT NULL") > -1) ) {
                $filters[] = $key . ' ' . $value;
                
            } else if( strpos($key_upper, ' BETWEEN') > -1 && is_array($value) ) { // BETWEEN
				if( count($value) < 2 ) {
					trigger_error('The value array for the BETWEEN expression, must have atleast 2 entries');
				} else {
					$filters[] = $key . ' ' . $db->sanitizeInput($value[0]) . ' AND ' . $db->sanitizeInput($value[1]);
				}
				
			} else if (in_array(trim($key_upper), $conditionals) && is_array($value)) { // OR, XOR, NOR, AND

                $block = $this->__buildFilters($value);

                $filters[] = "(" . implode(" " . $key_upper . " ", $block) . ")";
                
            } else if ($key && $value) {
                $filters[] = $key . " = " . $db->sanitizeInput($value, $db->getValueType($value));
            } else if ($key && !$value) {
                $filters[] = $key . " IS NULL ";
            }
        }
        
        return $filters;
    }

    /**
     * Returns a count of the total number of this object in the database. Additional
     * filters can be specified to help retrieve a specific count as desired.
     *
     * To retrieve a count for a distinct number of items, simply supply the fields
     * as a string. e.g.
     *
     * $this->count( array(
     *    'fields' => 'DISTINCT FirstName'
     * ) );
     *
     * @param mixed $options
     * @return int
     */
    public function count($options = NULL) {
        // count only the primary keys to improve performance
        $fields = $this->primaryKey;

        if (is_array($options) && isset($options['fields'])) {
            $fields = $options['fields'];
            unset($options['fields']);
        }

        $dOptions = array(
            'fields' => "count($fields) AS cnt"
        );

        if ($options != NULL) {
            $options = array_merge($dOptions, $options);
        } else {
            $options = $dOptions;
        }

        $data = $this->find($options);

        return $data[0]->getData('cnt');
    }
    
    public function updateAll( $fields, $conditions = NULL ) {
        
        if( !is_array($fields) && !is_string($fields) ) {
            return;
        }
        
        $db = $this->getDatabase();
        $query = "UPDATE " . $this->getTableName() . " AS " . get_class() . " SET ";
        
        if( is_array($fields) ) {
            $parts = array();
            
            foreach($fields as $key => $value) {
                $parts[] = $key . " = " . $db->sanitizeInput($value);
            }
            
            $fields = implode(", ", $parts);            
            
            $query = sprintf("%s %s", $query, $fields);
        }
        
        else if( is_string($fields) ) {
            $query = sprintf("%s %s", $query, $fields);
        }
        
        if( is_array($conditions) ) {
            $filters = $this->__buildFilters($conditions);
            $conditions = implode(" AND ", $filters);
            
            $query = sprintf("%s WHERE %s", $query, $conditions);
        }
        
        else if( is_string($conditions)) {
            $query = sprintf("%s WHERE %s", $query, $conditions);
        }
        
        //echo $query;
        
        $this->query($query);
    }

    /**
     * Deletes all records for the Model in the database
     * 
     * @param array $conditions 
     */
    public function deleteAll($conditions = NULL) {
        
        $query = "DELETE FROM " . $this->getTableName();
        
        if( is_array($conditions) ) {
            $filters = $this->__buildFilters($conditions);
            $query = sprintf("%s WHERE %s", $query, implode(" AND ", $filters));
        }
        
        else if( is_string($conditions) ) {
            $query = sprintf("%s WHERE %s", $query, $conditions );
        }
        
        //echo $query;
        $this->query($query);
    }

    /**
     * Runs the specified SQL Query and returns instanceof the Model or its current
     * subclass implementation
     * 
     * @param string $query the query to run
     * @return Model returns a Model instance
     */
    public function query($query, $objects = true) {
        $db = $this->getDatabase();

        if ($this->debug) {
            print_r($query);
        }

        $db->query($query, true);

        $items = array();

        for ($i = 0; $i < $db->getResultCount(); $i++) {
			$objClassName = get_class($this);
			
			if( $objects ) {
				$items[] = new $objClassName($db->getRow($i), $this->getTableName());
			}
			
			else {
				$items[] = array($objClassName => $db->getRow($i));
			}
        }

        return $items;
    }

    /**
     * Tests if the object supplied is the same as this object based on the factor that
     * they have similar KeyValues
     * 
     * @param this $o
     * @return Model
     */
    public function equals($o) {

        if ($o == NULL || !($o instanceof $this)) {
            trigger_error("Cannot Compare With NULL Object Or Object Which Is Not An Instance Of This Class");
            return false;
        }

        if ($o->keyValue == NULL || $o->keyValue == "") {
            trigger_error("Cannot Compare An Item Which Has No Primary Key Defined");
            return false;
        }

        return $o instanceof $this && $o->primaryKey == $this->primaryKey && $o->keyValue == $this->keyValue;
    }

}

?>